跳到主要内容

Go 的 HTTP 标准库-服务端

快速实现服务端

比如我们可以实现一个服务端,无论接收到什么请求,都返回字符串 “Hello World!”

package main

import (
"log"
"net/http"
)

// 这里创建一个类型是为了实现 Handler 接口
type server int

func (h *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Println(r.URL.Path)
w.Write([]byte("Hello World!"))
}

func main() {
var s server
http.ListenAndServe("localhost:9999", &s)
}

使用 curl 命令测试

$ curl http://localhost:9999  
Hello World!
$ curl http://localhost:9999/abc
Hello World!

Go 程序日志输出

2021/10/28 11:23:35 /
2021/10/28 11:23:43 /abc

带路由的服务

除了上面那种创建服务的方式,还可以直接无需 server 对象,直接使用默认 Server

import (
"net/http"
)

func HandleTest(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello world"))
}

func main() {
http.HandleFunc("/test", HandleTest)
http.ListenAndServe(":9998", nil)
}

我们可以在 HTTP 响应头中通过 Set-Cookie 字段设置 Cookie,然后在下次请求时就会在请求头 Cookie 中自动包含新增的 Cookie。

Go 语言官方提供的 http 包虽然对 HTTP 编程提供了丰富的 API,但是没有提供官方的 Session 实现。如果在 Web 应用中使用到了 Session,需要自行去实现

Go 专门提供了一个 http.Cookie 结构体来表示 Cookie,其中的字段对应着 Cookie 的所有属性:

// A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an
// HTTP response or the Cookie header of an HTTP request.
//
// See https://tools.ietf.org/html/rfc6265 for details.
type Cookie struct {
Name string
Value string

Path string // optional
Domain string // optional
Expires time.Time // optional
RawExpires string // for reading cookies only

// MaxAge=0 means no 'Max-Age' attribute specified.
// MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'
// MaxAge>0 means Max-Age attribute present and given in seconds
MaxAge int
Secure bool
HttpOnly bool
SameSite SameSite
Raw string
Unparsed []string // Raw text of unparsed attribute-value pairs
}

上面各个熟悉都很容易理解,这里,我们重点介绍下 Expires 字段:

  • 如果 Expires 字段为空,则 Cookie 就是 Session Cookie 或者叫临时 Cookie,这个 Cookie 会随着浏览器的关闭而销毁。
  • 如果设置了 Expires,那么这个 Cookie 就是持久 Cookie,直到过期时间后才会销毁。

有两种方法来设置过期时间:一种是直接设置 Expires 字段,一种是设置 MaxAge 字段。

前者表示到期的具体时间点,后者表示 Cookie 的有效时长(单位是秒)。

这并不是 Go 语言的设计,而是不同浏览器的混乱标准使然,比如虽然 HTTP/1.1 有意废弃 Expires,不过 IE 6、7、8 却不支持 MaxAge 字段。通常,考虑到默认时区问题,本地时间不可靠,推荐通过 MaxAge 字段设置 Cookie 过期时间,不过对于 Web 应用而言,通常不设置过期时间,让 Cookie 随着浏览器关闭而失效即可。

了解了 Cookie 的基本结构,以及如何在 Go 语言中表示后,我们尝试在 HTTP 响应中通过设置 Set-Cookie 头新增 Cookie 并将其发送给客户端浏览器。

cookie := http.Cookie{
Name: "_cookie",
Value: session.Uuid,
HttpOnly: true,
Path: "/",
}

http.SetCookie(writer, &cookie)
http.Redirect(writer, request, "/", 302)

上述代码是在用户认证通过后将 Session ID 通过 Cookie 存储到客户端,以便后续记住用户登录状态,直到用户关闭浏览器(没有设置过期时间)。

通过这段代码,可以看出要在响应中发送 Cookie,需要先通过 http.Cookie 初始化一个 Cookie 对象,再通过 http.SetCookie 方法将这个 Cookie 写入到 HTTP 响应中,这样发送响应给客户端的时候就会带上这个 Cookie 了。

不通过写入 Cookie 到 HTTP 响应,而是直接通过 HTTP 响应头 Set-Cookie 来设置 Cookie

func SetCookie(w http.ResponseWriter, r *http.Request)  {
c1 := http.Cookie{
Name: "username",
Value: url.QueryEscape("张三"),
HttpOnly: true,
}

c2 := http.Cookie{
Name: "website",
Value: "https://example.com",
HttpOnly: true,
}

w.Header().Add("Set-Cookie", c1.String())
w.Header().Add("Set-Cookie", c2.String())
fmt.Fprintln(w, "通过 HTTP 响应头发送 Cookie 信息")
}

由于 Cookie 值包含了中文字符,需要通过 url.QueryEscape 方法进行 URL 编码,否则无法正常显示。

当然也可以通过 http.SetCookie 方法写入 Cookie 到 HTTP 响应来实现,对应的代码如下,这样做更便捷:

http.SetCookie(w, &c1)
http.SetCookie(w, &c2)

还可以通过 Expires/MaxAge 设置 Cookie 的有效期:

c1 := http.Cookie{
Name: "username",
...
Expires: time.Now().AddDate(0, 0, 1), // Cookie 有效期设置为1天
}

c2 := http.Cookie{
Name: "website",
...
MaxAge: 1000, // Cookie 有效期设置为 1000s
}

这样,就可以在响应中看到对应的 Cookie 有效期了:

一旦通过 Set-Cookie 响应头将 Cookie 信息发送到客户端浏览器,那么在 Cookie 有效期内,下次同域名下的用户请求将自动在请求头中包含对应的 Cookie 信息,比如我们访问 http://localhost:8080,就可以在请求头 Cookie 中看到上次响应返回的 Cookie:

要在服务端获取这些 Cookie 信息,可以通过读取请求头的方式:

cookie := r.Header.Get("Cookie")

但是这种方式读取的 Cookie 字符串值还需要进行解析,才能得到每个 Cookie 的值,为此可以通过更加便捷的专门用于读取每个 Cookie 的 r.Cookie 方法(r 表示 HTTP 请求对象实例)

func GetCookie(w http.ResponseWriter, r *http.Request)  {
c1, err := r.Cookie("username")
if err != nil {
fmt.Fprintln(w, "名为 username 的 Cookie 不存在")
return
}
username, _ := url.QueryUnescape(c1.Value)
c2, err := r.Cookie("website")
if err != nil {
fmt.Fprintln(w, "名为 website 的 Cookie 不存在")
return
}
website := c2.Value
fmt.Fprintf(w, "从用户请求中读取的 Cookie: {username: %s, website: %s}\n", username, website)
}

需要注意的是 r.Cookie 方法返回的是指针类型的 Cookie 对象和一个错误信息,需要通过调用 Cookie 对象上的 Value 属性返回对应的 Cookie 值,对于 username 而言,还需要通过 url.QueryUnescape 对编码值进行解码。

如果想要一次性获取所有 Cookie,还可以通过 r.Cookies() 方法,该方法会返回的是 Cookie 对象指针类型切片:

cookies := r.Cookies()
c1 := cookies[0] // username=%E5%AD%A6%E9%99%A2%E5%90%9B
c2 := cookies[1] // website=https://example.com

如果想要在 Cookie 过期之前提前删除 Cookie,可以将 MaxAge 设置为小于 0 的值即可:

c2 := http.Cookie{
Name: "website",
Value: "https://example.com",
HttpOnly: true,
MaxAge: -1, // Cookie 有效期设置为 -1,就会在当前响应发送给客户端后销毁该 Cookie
}

如果用 Expires 字段来设置的话,可以设置 Unix 时间戳的值为 1(对应的绝对时间是 1970-01-01 08:00:01 +0800 CST,也就是一个过去的时间):

c2 := http.Cookie{
Name: "website",
Value: "https://example.com",
HttpOnly: true,
Expires: time.Unix(1, 0), // Cookie 有效期设置为过去的时间
}

所谓一次性消息,指的是页面重新加载后消息就不存在了,也就是该消息只能被读取一次,不管你用不用它都不复存在了。

func SetWelcomeMessage(w http.ResponseWriter, r *http.Request)  {
msg := "hello world"
cookie := http.Cookie{
Name: "welcome_message",
Value: base64.URLEncoding.EncodeToString([]byte(msg)),
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/get_welcome_message", 302)
}

读取 Body 的数据

// ...

func myPost(w http.ResponseWriter, r *http.Request) {
s, _ := ioutil.ReadAll(r.Body) //把 body 内容读入字符串 s
fmt.Fprintf(w, "%s", s) //在返回页面中显示内容。
}

接受 GET 的参数

// 处理GET请求
func handleGet(writer http.ResponseWriter, request *http.Request) {
query := request.URL.Query()

// 第一种方式
// id := query["id"][0]

// 第二种方式
id := query.Get("id")

fmt.Printf("GET: id=%s\n", id)

fmt.Fprintf(writer, `{"code":0}`)
}

响应数据

这里只记录 HTTP 基本使用那篇笔记没有的部分

快速响应错误

func ServeHTTP(resp http.ResponseWriter, req *http.Request) {
// ....
http.Error(resp, "bad request", http.StatusBadRequest)
}

文件上传功能实现

文件解析和读取

func UploadImage(w http.ResponseWriter, r *http.Request)  {
r.ParseMultipartForm(1024 * 1024) // 最大支持 1024 KB,即 1 M
name := r.MultipartForm.Value["name"] // 文件名
image := r.MultipartForm.File["image"] // 图片文件

fmt.Println("图片上传成功: ", name[0])

file, err := image[0].Open()
if err == nil {
data, err := ioutil.ReadAll(file) // 读取二进制文件字节流
if err == nil {
fmt.Fprintln(w, string(data)) // 将读取的字节信息输出
}
}
}

在这段代码中,读取到文件数据后,将其赋值给 image,注意此时 image 是一个 FileHeader 指针数组,也就是说,通过一个字段名可以持有多个文件对象,这里只上传一张图片,那就是数组中的第一个对象,调用 FileHeader 的 Open 方法打开字节流,再调用 ioutil.ReadAll 读取,最后将结果以字符串格式输出到响应实体,如果图片上传并读取成功,最终我们会在响应中看到这张上传的图片。

在 Postman 中测试图片上传,在响应实体中看到上传的图片,则表示图片上传成功:

保存上传文件

可以将其保存到服务器指定目录:

func UploadImage(w http.ResponseWriter, r *http.Request)  {
r.ParseMultipartForm(1024 * 1000) // 最大支持 1024 KB,即 1 M
name := r.MultipartForm.Value["name"][0] // 文件名
image := r.MultipartForm.File["image"][0] // 图片文件
file, err := image.Open()
if err == nil {
data, err := ioutil.ReadAll(file) // 读取二进制文件字节流
if err == nil {
// fmt.Fprintln(w, string(data)) // 将读取的字节信息输出
// 将文件存储到项目根目录下的 images 子目录
// 从上传文件中读取文件名并获取文件后缀
names := strings.Split(image.Filename, ".")
suffix := names[len(names) - 1]
// 将上传文件名字段值和源文件后缀拼接出新的文件名
filename := name + "." + suffix
// 创建这个文件
newFile, _ := os.Create("images/" + filename)
defer newFile.Close()
// 将上传文件的二进制字节信息写入新建的文件
size, err := newFile.Write(data)
if err == nil {
fmt.Fprintf(w, "图片上传成功,图片大小: %d 字节\n", size / 1000)
}
}
}
if (err != nil) {
fmt.Fprintln(w, err)
}
}

注意,这个根目录是项目的根目录,跟 main 函数所在目录无关,执行时如果项目根目录没有 images 目录,会创建失败

此外,和 FormValue 和 PostFormValue 类似,还有一个 FormFile 用于快速获取上传文件:

file, _, err := r.FormFile("image")

这样一来,就不再需要额外运行 r.ParseMultipartForm 解析文件数据了,底层会自动处理,并且默认的内存最大值是 32 MB。

多文件上传处理

多目录上传是一样的,比如这里示例下,将 UploadImage 中获取的图片调整为第二个:

func UploadImage(w http.ResponseWriter, r *http.Request)  {
r.ParseMultipartForm(1024 * 1000) // 最大支持 1024 KB,即 1 M
name := r.MultipartForm.Value["name"][0] // 文件名
image := r.MultipartForm.File["image"][1] // 图片文件
...
}

重启 HTTP 服务器,重新上传文件:

就可以在服务端看到这个新上传的文件了:

Server 优雅的终止

采用常规方式启动一个 Golang http 服务时,若服务被意外终止或中断,即未等待服务对现有请求连接处理并正常返回且亦未对服务停止前作一些必要的处理工作,这样即会造成服务硬终止。这种方式不是很优雅。

参看如下代码,该 http 服务请求路径为根路径,请求该路径,其会在 2s 后返回 hello。

var addr = flag.String("server addr", ":8080", "server address")

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
fmt.Fprintln(w, "hello")
})
http.ListenAndServe(*addr, nil)
}

若服务启动后,请求 http://localhost:8080/ ,然后使用 Ctrl+C 立即中断服务,服务即会立即退出(exit status 2),请求未正常返回(ERR_CONNECTION_REFUSED),连接即马上断了。

接下来介绍使用 http.Server 的 Shutdown 方法结合 signal.Notify 来优雅的终止服务。

Golang http.Server 结构体有一个终止服务的方法 Shutdown,使用 Shutdown 可以优雅的终止服务,其不会中断活跃连接。其工作过程为:首先关闭所有开启的监听器,然后关闭所有闲置连接,最后等待活跃的连接均闲置了才终止服务。

如下代码:

  1. 创建一个 http.Server 实例,指定端口与 Handler。
  2. 声明一个 processed chan,其用来保证服务优雅的终止后再退出主 goroutine。
  3. 新启一个 goroutine,其会监听 os.Interrupt 信号,一旦服务被中断即调用服务的 Shutdown 方法,确保活跃连接的正常返回(本代码使用的 Context 超时时间为 3s,大于服务 Handler 的处理时间,所以不会超时)。
  4. 处理完成后,关闭 processed 通道,最后主 goroutine 退出。
var addr = flag.String("server addr", ":8080", "server address")

func main() {
// handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
fmt.Fprintln(w, "hello")
})

// server
srv := http.Server{
Addr: *addr,
Handler: handler,
}

// make sure idle connections returned
processed := make(chan struct{})
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
<-c

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); nil != err {
log.Fatalf("server shutdown failed, err: %v\n", err)
}
log.Println("server gracefully shutdown")

close(processed)
}()

// serve
err := srv.ListenAndServe()
if http.ErrServerClosed != err {
log.Fatalf("server not gracefully shutdown, err :%v\n", err)
}

// waiting for goroutine above processed
<-processed
}

通知服务启动了

l, err := net.Listen("tcp", ":8080")
if err != nil {
// handle error
}

// Signal that server is open for business.

if err := http.Serve(l, rootHandler); err != nil {
// handle error
}

Reference